第8章 基本UDP套接字编程

8.1 概述

UDP是无连接不可靠的数据报协议,非常不同于TCP提供的面向连接的可靠字节流。

有些场合确实适合使用UDP,常见的应用程序有:

  • DNS:域名系统
  • NFS:网络文件系统
  • SNMP:简单网络管理协议

image-20200815203716894

8.2 recvfrom和sendto函数

类似于标准的read和write函数,不过需要三个额外的参数:

#include <sys/socket.h>
//成功则均返回读或写的字节数,出错返回-1
ssize_t recvfrom(int sockfd, void *buff, size_t nbytes, int flags,
                struct sockadd *from, socklen_t *addrlen);
ssize_t sendto(int sockfd, void *buff, size_t nbytes, int flags,
              const struct sockaddr *to, socklen_t addrlen);
  • 前三个参数sockfd、buff和nbytes等同于read和write函数的三个参数:描述符、指向读入或写出缓冲区的指针和读写字节数。

  • flags总是置0

  • sendto的to参数指向一个含有数据报接收者的协议地址(例如IP地址及端口号)的套接字地址结构,大小由addrlen参数指定(是一个整数值

  • recvfrom的from参数指向一个将由该函数在返回时填写数据报发送者的协议地址(例如IP地址及端口号)的套接字地址结构,该套接字地址中填写的字节数存放在addrlen参数所指的整数中返回给调用者(是一个指向整数值的指针(值-结果传参)

recvfrom最后两个参数类似accept最后两个参数:返回时其中套接字地址结构内容告诉我们是谁发送了数据报(UPD情况下)或是谁发起了连接(TCP情况下)。

sendto的最后两个参数类似于connect最后两个参数:调用时其中套接字地址结构被我们填入数据报发往(UDP情况下)或与之建立连接(TCP情况下)的协议地址

**写一个长度为0的数据报是可行的。**在UDP情况下,会形成一个只包含IP首部和UDP首部而没有数据的IP数据报,即recvfrom返回0值是可接受的。

UDP是无连接的,不存在关闭连接之类的事情。

8.3 UDP回射服务器程序:main函数

image-20200815211416476

#include    "unp.h"

int
main(int argc, char **argv)
{
    int                    sockfd;
    struct sockaddr_in     servaddr, cliaddr;

    //通过指定SOCK_DGRAM,创建一个UDP套接字
    sockfd = Socket(AF_INET, SOCK_DGRAM, 0);

    bzero(&servaddr, sizeof(servaddr));
    servaddr.sin_family      = AF_INET;
    servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
    servaddr.sin_port        = htons(SERV_PORT);

    Bind(sockfd, (SA *) &servaddr, sizeof(servaddr));

    dg_echo(sockfd, (SA *) &cliaddr, sizeof(cliaddr));
}

8.4 UDP回收服务器程序:dg_echo函数

#include    "unp.h"

void
dg_echo(int sockfd, SA *pcliaddr, socklen_t clilen)
{
    int            n;
    socklen_t      len;
    char           mesg[MAXLINE];

    //迭代服务器,永不终止,无连接
    for ( ; ; ) {
        len = clilen;
        n = Recvfrom(sockfd, mesg, MAXLINE, 0, pcliaddr, &len);

        Sendto(sockfd, mesg, n, 0, pcliaddr, len);
    }
}

大多数TCP服务器是并发的,大多数UDP服务器是迭代的。每个UDP套接字都有一个接收缓冲区,到达该套接字的每个数据报都进入这个套接字接收缓冲区,当进程调用recvfrom函数时,缓冲区中的下一个数据报以FIFO顺序返回给进程。

dg_echo函数是协议无关的:调用者分配一个正确大小的套接字地址结构,将其地址指针和大小传参给dg_echo,dg_echo绝不查看该结构的内容,而是把一个指向该结构的指针传递给recvfrom和sendto。

image-20200815212534596

image-20200815212550160

8.5 UDP回射客户程序:main函数

#include    "unp.h"

int
main(int argc, char **argv)
{
    int                    sockfd;
    struct sockaddr_in     servaddr;

    if (argc != 2)
        err_quit("usage: udpcli <IPaddress>");

    bzero(&servaddr, sizeof(servaddr));
    servaddr.sin_family = AF_INET;
    servaddr.sin_port = htons(SERV_PORT);
    Inet_pton(AF_INET, argv[1], &servaddr.sin_addr);

    sockfd = Socket(AF_INET, SOCK_DGRAM, 0);

    dg_cli(stdin, sockfd, (SA *) &servaddr, sizeof(servaddr));

    exit(0);
}

8.6 UDP回射客户程序:dg_cli函数

dg_cli函数也是协议无关的,不过main函数都是协议相关的。

#include    "unp.h"

void
dg_cli(FILE *fp, int sockfd, const SA *pservaddr, socklen_t servlen)
{
    int     n;
    char    sendline[MAXLINE], recvline[MAXLINE + 1];

    while (Fgets(sendline, MAXLINE, fp) != NULL) {

        //首次调用sendto时没有绑定一个本地接口,内核在此时为它选择一个临时端口
        Sendto(sockfd, sendline, strlen(sendline), 0, pservaddr, servlen);

        //最后两个参数是空指针,表示并不关心应答数据报由谁发送
        //任何接收的数据报均被认为是服务器的内容
        n = Recvfrom(sockfd, recvline, MAXLINE, 0, NULL, NULL);

        recvline[n] = 0;    /* null terminate */
        Fputs(recvline, stdout);
    }
}

8.7 数据报的丢失

UDP客户/服务器例子是不可靠的:如果客户数据报到达服务器,但是服务器的应答丢失了,则客户将永远阻塞于dg_cli函数的recvfrom调用,等待一个永远不会到达的服务器应答。

防止永久阻塞的一般方法是给客户的recvfrom调用设置一个超时,但是这并不是完整的解决办法。

8.8 验证接收到的响应

知道客户临时端口的任何进程都可以往客户发送数据报,而这些数据报会与正常的服务器应答混杂。

通过在dg_cli函数的recvfrom调用中,通知内核返回数据报发送者的地址,通过比较recvfrom在值-结果传参中返回的长度,然后用memcmp比较套接字地址结构本身,验证接收到的响应。

#include    "unp.h"

void
dg_cli(FILE *fp, int sockfd, const SA *pservaddr, socklen_t servlen)
{
    int                    n;
    char                sendline[MAXLINE], recvline[MAXLINE + 1];
    socklen_t              len;
    struct sockaddr        *preply_addr;

    preply_addr = Malloc(servlen);

    while (Fgets(sendline, MAXLINE, fp) != NULL) {

        Sendto(sockfd, sendline, strlen(sendline), 0, pservaddr, servlen);

        len = servlen;
        n = Recvfrom(sockfd, recvline, MAXLINE, 0, preply_addr, &len);
        if (len != servlen || memcmp(pservaddr, preply_addr, len) != 0) {
            printf("reply from %s (ignored)\n",
                    Sock_ntop(preply_addr, len));
            continue;
        }

        recvline[n] = 0;    /* null terminate */
        Fputs(recvline, stdout);
    }
}

如果服务器运行在只有单个IP的主机上,那么新版的客户将正常工作,如果服务器主机是多宿的,该客户可能失败:发送到服务器数据的地址和接收服务器数据的地址可能不同。

解决办法:

  • 将返回的IP地址通过DNS中查找服务器主机的名字来验证主机的域名
  • 为服务器每个IP绑定一个套接字,在所有套接字上使用select,使应答的套接字上绑定的IP地址就是客户请求的目的IP地址

8.9 服务器进程未运行

服务器进程不启动的情况下,客户永远阻塞在它的recvfrom调用,等待一个永不出现的服务器应答:

  • 客户主机进行ARP请求后获取服务器地址
  • 客户发送数据后,返回“端口不可达”的ICMP消息,但该消息不会返回给客户进程

这个ICMP错误称为异步错误,该错误由sendto引起,但是sendto本身却成功返回。UDP输出操作成功后仅仅返回表示在接口输出队列中具有存放所形成IP数据报的空间,该ICMP错误直到后来才返回,故称其为异步。

一个基本的规则:对于一个UDP套接字,由它引起的异步错误却并不返回给它,除非它已连接。ICMP出错信息包含引起错误的数据报的IP首部和UDP首部,而recvfrom可以返回的信息只有errno值,没法返回出错数据报的目的IP地址和目的UDP端口号,因此做出决定:仅在进程已将其UDP套接字连接到恰恰一个对端后,这些异步错误才返回给进程。

只要SO_BSDCOMPAT套记者选项没有开启,Linux甚至对未连接的套接字也返回大多数ICMP “destination unreachable”错误。

8.10 UDP程序例子小结

image-20200816101704301

  • 客户临时端口是在第一次调用sendto时一次性选定,不能改变
  • 客户的IP地址可以随客户发送的每个UDP数据报而变动

image-20200816101727333

  • 对于UDP套接字来是,目的IP地址只能通过为IPv4设置IP_RECVDSTADDR套接字选项(或为IPv6设置IPV6_PKTINFO套接字选项)然后调用recvmsg取得。

8.11 UDP的connect函数

UDP套接字的connect没有三路握手过程,内核只是检查是否存在立即可知的错误,记录对端的IP地址和端口号,然后立即返回到调用进程:

  • 未连接UDP套接字,新创建的UDP套接字默认如此
  • 已连接UDP套接字,对UDP套接字调用connect的结果

已连接UDP套接字对比默认的未连接套接字的三个变化:

  • 不能给输出操作指定目的IP地址和端口号,而是改用write或send

    • 不使用sendto
    • 使用sendto,但是不能指定目的地址,sendto的第五个参数为空指针,第六个参数为0

    POSIX规范指出当第五个参数是空指针时,第六个参数的取值就不再考虑

  • 不必使用recvfrom以获悉数据报的发送者,而改用read、recv或recvmsg。限制一个已连接UDP套接字能且仅能与一个对端交换数据报。

    • 准确说是仅能与一个IP地址交换数据报,可能connect到多播或广播地址
  • 已连接UDP套接字的异步错误会返回给它们所在的进程,而未连接UDP套接字不接受任何异步错误

image-20200816103351241

image-20200816103527775

UDP客户进程或服务进程只在使用自己的UDP套接字与确定的唯一对端进行通信时,才可以调用connect,调用connect的通常是UDP客户,不过有些网络应用中的UDP服务器会与单个客户长时间通信(如TFTP),这种情况下,客户和服务器都可能调用connect。

8.11.1 给一个UDP套接字多次调用connect

一个已连接UDP套接字的进程可由下列两个目的再次调用connect:

  • 指定新的IP地址和端口号
    • TCP套接字connect只能调用一次
  • 断开套接字
    • 为了断开一个已连接UDP套接字,我们再次调用connect时把套接字地址结构的地址族成员(对于IPv4为sin_family,对于IPv6为sin6_family)设置为AF_UNSPEC,这样做可能会返回一个EAFNOSUPPORT错误,不过没关系
    • 有些系统可以用空的套接字地址结构指针调用connect,POSIX规范和BSD手册只是提示必须使用一个空地址而没有提到出错返回值。最便于移植的解决办法是:清零一个地址结构后把它的地址族成员设置为AF_UNSPEC,再把它传递给connect

8.11.2 性能

在一个未连接UDP套接字上给两个数据报调用sendto函数涉及6个步骤(源自Berkeley内核):

  • 连接套接字(第一次很可能搜索路由表)
  • 输出第一个数据报
  • 断开套接字连接
  • 连接套接字(第二次的目的地址可能等同第一次,则无需搜索路由表)
  • 输出第二个数据报
  • 断开套接字连接

当应用进程知道自己要给同一目的地址发送多个数据报时,显示连接套接字效率更高,调用connect后调用两次write涉及内核的执行步骤如下:

  • 连接套接字
  • 输出第一个数据报
  • 输出第二个数据报

8.12 dg_cli函数(修订版)

#include    "unp.h"

void
dg_cli(FILE *fp, int sockfd, const SA *pservaddr, socklen_t servlen)
{
    int            n;
    char        sendline[MAXLINE], recvline[MAXLINE + 1];

    Connect(sockfd, (SA *) pservaddr, servlen);

    while (Fgets(sendline, MAXLINE, fp) != NULL) {

        Write(sockfd, sendline, strlen(sendline));

        n = Read(sockfd, recvline, MAXLINE);

        recvline[n] = 0;    /* null terminate */
        Fputs(recvline, stdout);
    }
}

函数不查看传递给connect的套接字地址结构的内容,仍是协议无关的。

8.13 UDP缺乏流量控制

UDP套接字接收缓冲区:由UDP给某个特定套接字排队的UDP数据报数目受限于该套接字接收缓冲区的大小,可以使用SO_RCVBUF套接字选项修改改制。

增加流量控制的UDP示例:

#include    "unp.h"

#define    NDG        2000    /* datagrams to send */
#define    DGLEN    1400    /* length of each datagram */

void
dg_cli(FILE *fp, int sockfd, const SA *pservaddr, socklen_t servlen)
{
    int        i;
    char    sendline[DGLEN];

    for (i = 0; i < NDG; i++) {
        Sendto(sockfd, sendline, DGLEN, 0, pservaddr, servlen);
    }
}
#include    "unp.h"

static void    recvfrom_int(int);
static int    count;

void
dg_echo(int sockfd, SA *pcliaddr, socklen_t clilen)
{
    int            n;
    socklen_t      len;
    char           mesg[MAXLINE];

    Signal(SIGINT, recvfrom_int);

    n = 220 * 1024;
    Setsockopt(sockfd, SOL_SOCKET, SO_RCVBUF, &n, sizeof(n));

    for ( ; ; ) {
        len = clilen;
        Recvfrom(sockfd, mesg, MAXLINE, 0, pcliaddr, &len);

        count++;
    }
}

static void
recvfrom_int(int signo)
{
    printf("\nreceived %d datagrams\n", count);
    exit(0);
}

8.14 udp中的外出接口的确定

已连接UDP套接字还可以用来确定用于某个特定目的地址的外出接口。因为connect函数应用到UDP套接字时有一个副作用:内核选择本地IP地址(未使用bind),这个本地IP地址通过为目的地址搜索路由表得到外出接口,然后选用该接口的主IP地址而选定。

在UDP套接字上调用connect并不给对端主机发送任何信息,它完全是一个本地操作,只是保存对端的IP地址和端口号。

在一个未绑定端口号的UDP套接字上调用connect同时也给该套接字指派一个临时端口。

//使用connect来确定输出接口的UDP程序
#include    "unp.h"

int
main(int argc, char **argv)
{
    int                        sockfd;
    socklen_t                  len;
    struct sockaddr_in         cliaddr, servaddr;

    if (argc != 2)
        err_quit("usage: udpcli <IPaddress>");

    sockfd = Socket(AF_INET, SOCK_DGRAM, 0);

    bzero(&servaddr, sizeof(servaddr));
    servaddr.sin_family = AF_INET;
    servaddr.sin_port = htons(SERV_PORT);
    Inet_pton(AF_INET, argv[1], &servaddr.sin_addr);

    Connect(sockfd, (SA *) &servaddr, sizeof(servaddr));

    len = sizeof(cliaddr);
    Getsockname(sockfd, (SA *) &cliaddr, &len);
    printf("local address %s\n", Sock_ntop((SA *) &cliaddr, len));

    exit(0);
}

8.15 使用select函数的tcp和udp回射服务器程序

将并发TCP回射服务器程序与迭代UDP回射服务器程序组合成单个使用select来复用TCP和UDP套接字的服务器程序。

/* include udpservselect01 */
#include    "unp.h"

int
main(int argc, char **argv)
{
    int                    listenfd, connfd, udpfd, nready, maxfdp1;
    char                   mesg[MAXLINE];
    pid_t                  childpid;
    fd_set                 rset;
    ssize_t                n;
    socklen_t              len;
    const int              on = 1;
    struct sockaddr_in     cliaddr, servaddr;
    void                   sig_chld(int);

        /* create listening TCP socket */
    listenfd = Socket(AF_INET, SOCK_STREAM, 0);

    bzero(&servaddr, sizeof(servaddr));
    servaddr.sin_family      = AF_INET;
    servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
    servaddr.sin_port        = htons(SERV_PORT);

    //设置SO_REUSEADDR套接字选项防止该端口上已有连接存在
    Setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));
    Bind(listenfd, (SA *) &servaddr, sizeof(servaddr));

    Listen(listenfd, LISTENQ);

        /* create UDP socket */
    udpfd = Socket(AF_INET, SOCK_DGRAM, 0);

    bzero(&servaddr, sizeof(servaddr));
    servaddr.sin_family      = AF_INET;
    servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
    servaddr.sin_port        = htons(SERV_PORT);

    Bind(udpfd, (SA *) &servaddr, sizeof(servaddr));
/* end udpservselect01 */

/* include udpservselect02 */
    //给SIGCHLD建立信号处理程序,因为TCP连接将由某个子进程处理
    Signal(SIGCHLD, sig_chld);    /* must call waitpid() */

    FD_ZERO(&rset);
    maxfdp1 = max(listenfd, udpfd) + 1;
    for ( ; ; ) {
        FD_SET(listenfd, &rset);
        FD_SET(udpfd, &rset);
        if ( (nready = select(maxfdp1, &rset, NULL, NULL, NULL)) < 0) {
            if (errno == EINTR)
                continue;        /* back to for() */
            else
                //sig_chld信号处理程序可能会中断select调用,需要处理EINTR错误
                err_sys("select error");
        }

        if (FD_ISSET(listenfd, &rset)) {
            len = sizeof(cliaddr);
            connfd = Accept(listenfd, (SA *) &cliaddr, &len);
    
            if ( (childpid = Fork()) == 0) {    /* child process */
                Close(listenfd);    /* close listening socket */
                str_echo(connfd);    /* process the request */
                exit(0);
            }
            Close(connfd);            /* parent closes connected socket */
        }

        if (FD_ISSET(udpfd, &rset)) {
            len = sizeof(cliaddr);
            n = Recvfrom(udpfd, mesg, MAXLINE, 0, (SA *) &cliaddr, &len);

            Sendto(udpfd, mesg, n, 0, (SA *) &cliaddr, len);
        }
    }
}
/* end udpservselect02 */